Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

22장. 고루틴과 채널 기초

드디어 Go 의 간판 기능에 도착했다. 바로 동시성(concurrency)이다.

Go 의 동시성은 두 개의 도구를 중심으로 돈다.

  • 고루틴 — 가벼운 실행 단위
  • 채널 — 고루틴 사이의 통로

이 장의 목표는 다음과 같다.

  • 고루틴이 무엇이며 왜 가벼운지 이해하기
  • go 키워드로 동시 작업을 시작하기
  • 채널로 고루틴 사이에 값을 주고받기
  • select, WaitGroup 같은 기본 도구 익히기

문법은 단순하지만 사고방식은 처음에 낯설다. 짧은 예제 여러 개로 천천히 익혀 보자.


22.1 고루틴이란

고루틴(goroutine) 은 Go 런타임이 관리하는 가벼운 실행 단위다.

다른 언어의 스레드와 비슷한 자리에 있지만, 무게가 한참 다르다.

OS 스레드와의 차이

OS 가 직접 다루는 스레드는 꽤 무거운 자원이다.

항목OS 스레드고루틴
시작 비용무겁다 (수십 µs)가볍다 (수 µs 이하)
스택 크기보통 1~8 MB 고정시작 2~8 KB, 필요시 증가
컨텍스트 스위치커널 호출Go 런타임이 직접
동시에 띄울 수 있는 수수천 개 정도수십만 개도 가능

수치는 환경마다 다르지만 차수 자체가 다르다는 점이 중요하다.

고루틴 1만 개를 띄워도 시스템이 멈추지 않는다. OS 스레드를 1만 개 띄우려 했다면 보통은 그 전에 시스템이 비명을 지른다.

Go 런타임 스케줄러

고루틴이 가벼울 수 있는 이유는 Go 런타임 스케줄러가 따로 있기 때문이다.

대강의 그림은 이렇다.

  • OS 스레드 몇 개가 워커 풀처럼 떠 있다
  • 우리가 만든 수많은 고루틴이 그 워커 위에서 돌아간다
  • 어느 고루틴을 어느 스레드에 올릴지는 Go 런타임이 알아서 결정한다

즉, 고루틴은 OS 스레드 위에 한 겹 더 얹은 사용자 영역의 가벼운 스레드라고 이해하면 된다.

핵심 한 줄

고루틴은 OS 스레드보다 훨씬 가벼운 Go 런타임의 실행 단위다. 그래서 수십만 개도 무리 없이 띄울 수 있다.


22.2 고루틴 시작하기

문법은 놀라울 만큼 단순하다. 함수 호출 앞에 go 키워드만 붙이면 된다.

go f()

이 한 줄의 의미는,

  • f() 를 새로운 고루틴에서 실행한다
  • 호출한 쪽은 기다리지 않고 즉시 다음 줄로 진행한다

첫 예제

package main

import (
	"fmt"
	"time"
)

func say(msg string) {
	for i := 0; i < 3; i++ {
		fmt.Println(msg, i)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	go say("hello")
	say("world")
}

say("hello") 는 고루틴에서, say("world") 는 main 에서 동시에 돈다.

출력은 환경에 따라 섞여 나온다.

world 0
hello 0
hello 1
world 1
world 2
hello 2

순서가 매번 같지 않다는 점이 중요하다. 고루틴의 실행 순서는 보장되지 않는다.

익명 함수도 OK

별도로 함수를 정의하지 않고 익명 함수를 그 자리에서 실행할 수도 있다.

go func() {
	fmt.Println("from goroutine")
}()

마지막 () 가 익명 함수를 즉시 호출하는 부분이다. 이 호출에 go 가 붙어 고루틴이 된다.

매개변수도 전달할 수 있다.

name := "Alice"
go func(n string) {
	fmt.Println("hi", n)
}(name)

클로저로 바깥 변수를 그냥 캡처하는 것도 가능하지만, for 루프 안에서 잡을 때 흔한 함정이 있다. 그 함정은 25장에서 다시 다룬다.


22.3 main 종료와 고루틴

여기서 초보가 거의 100% 만나는 함정이 있다.

“왜 출력이 안 나오죠?”

다음 코드를 실행해 보자.

package main

import "fmt"

func main() {
	go fmt.Println("hello from goroutine")
}

결과는,

(아무것도 안 나옴)

원인은 한 줄로 요약된다.

main 함수가 끝나면 모든 고루틴은 강제로 종료된다.

go fmt.Println(...) 를 호출하자마자 main 은 다음 줄로 넘어가고, 그 다음 줄이 없으니 그대로 끝난다. 고루틴이 실제로 실행될 틈이 없다.

임시방편: time.Sleep

가장 단순한 회피책은 main 을 잠깐 재우는 것이다.

package main

import (
	"fmt"
	"time"
)

func main() {
	go fmt.Println("hello from goroutine")
	time.Sleep(100 * time.Millisecond)
}

이번엔 출력이 나온다.

hello from goroutine

하지만 이 방식은 좋은 해법이 아니다.

  • 얼마를 자야 하는지 정확히 알 수 없다
  • 너무 짧게 자면 여전히 못 끝낸다
  • 너무 길게 자면 프로그램이 느려진다

time.Sleep예제용 임시방편일 뿐이다. 실전에선 다음 두 가지 도구를 쓴다.

  • 채널로 끝났다는 신호를 받기
  • sync.WaitGroup 으로 일제히 기다리기

둘 다 이번 장에서 배운다.


22.4 채널이란

채널(channel) 은 고루틴 사이에 값을 전달하는 통로다.

비유하자면 타입이 있는 컨베이어 벨트다.

  • 한쪽 끝에서 값을 올린다 (송신)
  • 다른 쪽 끝에서 값을 집는다 (수신)
  • 한 번에 한 타입의 값만 흐른다

채널을 통해 두 고루틴은,

  • 값을 주고받고 (통신)
  • 서로 박자를 맞춘다 (동기화)

이 두 가지가 동시에 일어난다는 점이 채널의 매력이다.

채널 타입 표기

채널은 자신이 운반하는 값의 타입을 가진다.

chan int       // int 가 흐르는 채널
chan string    // string 이 흐르는 채널
chan []byte    // 바이트 슬라이스가 흐르는 채널

chan T 형태로 읽으면 된다.


22.5 채널 만들고 쓰기

생성

ch := make(chan int)

make 로 만든다. 용량을 적지 않으면 언버퍼 채널(unbuffered channel) 이 된다.

송신과 수신

ch <- 5       // 5 를 ch 에 보낸다 (송신)
x := <-ch     // ch 에서 값을 꺼낸다 (수신)

화살표가 채널을 향하면 송신, 채널에서 나오면 수신이다. 시각적으로 방향이 곧 의미라서 외우기 쉽다.

송수신은 서로를 기다린다

언버퍼 채널의 핵심 성질이다.

  • 송신 측은 누군가 받기 시작할 때까지 멈춰 있다
  • 수신 측은 누군가 보낼 때까지 멈춰 있다

즉, 만남이 성사돼야 둘 다 진행된다. 이걸 “랑데부(rendezvous)” 라고 부른다.

짧은 예제

package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		ch <- 42 // 송신
	}()

	v := <-ch // 수신
	fmt.Println(v)
}

실행 결과는 42.

흐름을 따라가 보자.

  1. main 이 채널을 만든다
  2. 고루틴을 띄우고 본인은 <-ch 에서 대기
  3. 고루틴이 ch <- 42 를 실행 → main 이 받는다
  4. main 이 값을 출력하고 종료

이 코드는 time.Sleep 없이도 안정적으로 동작한다. 채널이 박자를 맞춰 주기 때문이다.

한 가지 함정

ch := make(chan int)
ch <- 1        // 여기서 영원히 멈춤
fmt.Println(<-ch)

언버퍼 채널은 받는 쪽이 없으면 송신이 막힌다. 같은 고루틴(main) 안에서 송신과 수신을 차례로 하면, 첫 번째 송신이 영원히 끝나지 않는다.

이 상태가 바로 데드락(deadlock) 이다. Go 런타임이 모든 고루틴이 멈췄음을 감지하면 다음과 같은 패닉을 띄운다.

fatal error: all goroutines are asleep - deadlock!

이 메시지가 뜨면, “어딘가에서 받지 않는 채널에 보내고 있구나” 라고 의심해 보자.


22.6 채널 방향

채널은 양방향이 기본이지만, 함수 매개변수로 넘길 때는 방향을 제한할 수 있다.

chan T       // 양방향
chan<- T     // 송신 전용
<-chan T     // 수신 전용

화살표 위치가 곧 방향이다. chan<- 는 채널 안으로, <-chan 은 채널 밖으로 화살이 향한다.

왜 방향을 제한하나

함수 시그니처만 봐도 역할이 드러나기 때문이다.

func producer(out chan<- int) {
	for i := 0; i < 3; i++ {
		out <- i
	}
}

func consumer(in <-chan int) {
	for v := range in {
		fmt.Println(v)
	}
}

producer 는 보내기만 하고, consumer 는 받기만 한다. 컴파일러가 실수를 막아 준다. 예를 들어 producer 안에서 <-out 으로 받으려 하면 컴파일 에러가 난다.

호출하는 쪽은 양방향 채널을 만들어 넘기면 된다.

ch := make(chan int)
go producer(ch)
consumer(ch)

양방향 채널은 어느 쪽 매개변수에도 자동으로 맞는다. 반대로 단방향 채널을 양방향으로 다시 만들 순 없다.


22.7 버퍼 채널

지금까지 본 채널은 모두 언버퍼였다. 한 번에 한 값만 흐르고, 송수신이 서로를 기다린다.

버퍼 채널은 큐 역할을 한다.

ch := make(chan int, 3) // 용량 3

동작 규칙:

  • 버퍼에 자리가 있으면 송신은 즉시 통과
  • 가득 차면 송신이 막힌다 (받는 사람이 빼낼 때까지)
  • 비어 있으면 수신이 막힌다 (누군가 넣을 때까지)

예제

ch := make(chan int, 2)

ch <- 1
ch <- 2
// ch <- 3 // 여기서 막힌다

fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2

12 를 넣을 때는 막히지 않는다. 세 번째 송신부터 막힌다.

언제 버퍼를 쓰나

언버퍼가 기본이고, 버퍼는 “이런 이유로 필요해” 가 명확할 때만 쓴다.

쓸 만한 경우:

  • 송신과 수신의 속도가 들쭉날쭉할 때 버퍼로 잠깐 차이를 완충
  • 정해진 개수의 작업을 미리 큐에 쌓아 두고 워커가 가져가게 할 때
  • 결과를 모으는 채널의 용량을 미리 정해 둘 때

쓰면 안 되는 경우:

  • “데드락 났는데 버퍼 키우면 해결될까?” 식의 회피 → 보통 설계가 잘못된 신호다

버퍼는 성능을 위한 도구가 아니라 흐름의 모양을 정하는 도구다. 막연히 키운다고 빨라지는 일은 거의 없다.


22.8 채널 닫기와 range

채널을 다 썼다면 닫을(close) 수 있다.

close(ch)

닫힌 채널은 다음 성질을 가진다.

  • 새로 송신하면 패닉이 발생한다
  • 수신은 여전히 가능하지만, 버퍼에 남은 값을 다 꺼낸 뒤에는 제로값과 함께 ok = false 를 돌려준다

ok 받기

수신할 때 두 번째 값으로 ok 를 받을 수 있다.

v, ok := <-ch
if !ok {
	fmt.Println("채널이 닫혔다")
}

okfalse 면 채널이 닫혔고 더 받을 값도 없다는 뜻이다.

for range 로 자동 종료

매번 ok 를 확인하는 건 번거롭다. for range 가 이걸 자동으로 해 준다.

ch := make(chan int, 3)

go func() {
	for i := 0; i < 3; i++ {
		ch <- i
	}
	close(ch)
}()

for v := range ch {
	fmt.Println(v)
}

송신 측이 close(ch) 를 호출하면 수신 측의 range 가 자연스럽게 종료된다.

누가 닫아야 하나

관례는 분명하다.

보내는 쪽이 닫는다. 받는 쪽이 닫지 않는다.

이유는,

  • 받는 쪽은 송신이 끝났는지 알 길이 없다
  • 닫힌 채널에 송신하면 패닉이 난다
  • 보내는 쪽이 닫아야 패닉 위험이 없다

여러 송신자가 있다면 누가 닫을지 명확히 정해야 한다. 보통은 모든 송신자를 모은 뒤 마지막에 한 번만 닫는다. 이 패턴은 25장에서 다시 다룬다.


22.9 select 문 기초

여러 채널을 동시에 다뤄야 할 때 쓰는 도구가 select 다.

문법은 switch 와 닮았지만, 조건이 모두 채널 연산이다.

select {
case v := <-ch1:
	fmt.Println("ch1:", v)
case ch2 <- 42:
	fmt.Println("sent to ch2")
}

동작 규칙:

  • 모든 case 를 살펴 준비된 것을 찾는다
  • 준비된 case 가 하나면 그 case 실행
  • 여러 개면 임의로 하나를 골라 실행
  • 하나도 준비 안 됐으면 막혀서 기다린다

default 케이스

기다리고 싶지 않다면 default 를 쓴다.

select {
case v := <-ch:
	fmt.Println("got", v)
default:
	fmt.Println("nothing ready")
}
  • 준비된 case 가 있으면 그쪽 실행
  • 없으면 즉시 default 실행

이걸 “논블로킹(non-blocking) 채널 연산” 이라 부른다.

짧은 예제

package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go func() {
		time.Sleep(100 * time.Millisecond)
		ch1 <- "ping"
	}()
	go func() {
		time.Sleep(50 * time.Millisecond)
		ch2 <- "pong"
	}()

	for i := 0; i < 2; i++ {
		select {
		case v := <-ch1:
			fmt.Println("ch1:", v)
		case v := <-ch2:
			fmt.Println("ch2:", v)
		}
	}
}

ch2 가 먼저 준비되고 ch1 이 나중에 준비된다. 출력은 보통 이렇다.

ch2: pong
ch1: ping

select 는 채널 기반 동시성 코드의 거의 모든 곳에 등장한다. 25장의 패턴들도 결국 select 의 응용이다.


22.10 sync.WaitGroup

여러 고루틴을 띄워 놓고 모두 끝날 때까지 기다리기. 이게 의외로 자주 필요하다.

이때 쓰는 도구가 sync.WaitGroup 이다.

사용 패턴

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			fmt.Println("worker", id)
		}(i)
	}

	wg.Wait()
	fmt.Println("all done")
}

세 가지 메서드가 핵심이다.

메서드의미
Add(n)기다릴 고루틴 수를 n 만큼 늘린다
Done()고루틴 하나가 끝났음을 알린다
Wait()카운트가 0 이 될 때까지 막힌다

Done() 은 내부적으로 Add(-1) 과 같다.

관례: defer wg.Done()

고루틴 시작 직후 defer wg.Done() 을 적는 것이 관례다.

go func() {
	defer wg.Done()
	// ... 실제 작업 ...
}()

이유:

  • 작업 중간에 어떤 경로로 빠져나가든 항상 Done 이 호출된다
  • panic 이 나도 defer 는 실행되므로 안전하다

주의할 점

  • Add 는 고루틴을 띄우기 전에 호출한다
    • 고루틴 안에서 Add 를 하면 Wait 가 먼저 0을 보고 빠져나갈 수 있다
  • Done 을 한 번 더 호출하면 카운트가 음수가 되며 패닉이 난다
  • WaitGroup 은 값으로 복사하지 않는다
    • 함수에 넘길 때는 포인터로

채널 vs WaitGroup

같은 “기다림“이라도 도구를 나눠 쓰자.

상황도구
결과 값을 모아야 한다채널
끝났다는 사실만 알면 된다WaitGroup
둘 다 필요하다결과 채널 + WaitGroup

마지막 경우의 정석 패턴은 25장에서 다룬다.


22.11 정리

이 장에서 살펴본 내용:

  • 고루틴은 Go 런타임이 관리하는 가벼운 실행 단위다
  • go f() 한 줄로 새 고루틴을 시작한다
  • main 이 끝나면 모든 고루틴이 강제 종료된다
  • 채널은 타입이 있는 통로이며 송수신을 동기화한다
  • 언버퍼 채널은 송수신이 만나야 진행되고, 버퍼 채널은 큐처럼 동작한다
  • 채널 방향(chan<-, <-chan)으로 의도를 분명히 한다
  • closefor range 로 송신 종료를 깔끔히 전달한다
  • select 는 여러 채널 중 준비된 것을 처리한다
  • sync.WaitGroup 으로 고루틴 묶음의 끝을 기다린다

도구는 이제 손에 잡혔다. 하지만 도구가 있다고 안전한 코드가 되진 않는다. 여러 고루틴이 같은 데이터를 만지면 의외로 쉽게 망가진다.

다음 장에서는 그 망가지는 양상부터 들여다보고, 가장 기본적인 해결 도구인 뮤텍스(Mutex) 를 배운다.